Skip to content

tui: don't let a dead host-key prompt hold the keyboard - #4

Merged
ralyodio merged 1 commit into
mainfrom
fix/tui-zombie-hostkey-prompt
Aug 30, 2026
Merged

tui: don't let a dead host-key prompt hold the keyboard#4
ralyodio merged 1 commit into
mainfrom
fix/tui-zombie-hostkey-prompt

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Reported as: "once it syncs i can't get back to previous screen in tui" — after a sync to a remote endpoint (dev:me).

What was happening

The host-key prompt owns the keyboard while it is up, which is correct — a fingerprint should not be dismissable by mashing keys. What made that dangerous is that it could outlive the connection that asked.

Point a pane at a host DiskPush has not seen, then take fifteen seconds to actually compare the fingerprint. ssh2's readyTimeout (connectTimeoutSeconds, 15s) fires, SshSession.connect rejects with "Timed out while waiting for handshake", the pane shows that error — and this.hostKey is never cleared. The question stays on screen with nobody behind it.

From then on every key lands in this branch:

if (this.hostKey) {
  if (isChar(key, 'y') || ...) this.hostKey.decide(true)
  else if (key === 'escape' || ...) this.hostKey.decide(false)
  return true          // <- q lands here too
}

decide resolves a promise nobody awaits, so answering does nothing useful — and because the branch only ever returns true, q never quits. Arrows do nothing, panes never move, and Ctrl-C is the only way out. The app looks frozen because it is.

It reads as a sync bug because the post-sync reload is what re-enters session() and raises the question again.

The fix

Two changes, neither of which loosens the prompt:

  • session() clears the question in a finally. Whoever asked is gone, so the question goes with them. On the success path this is already null (decide clears it), so it is a no-op there.
  • q quits from inside the prompt. Everything else is still deliberately swallowed — an arrow must not leak through to the panes behind it — but a dialog that can trap you in the app is worse than one you can leave, and q is the quit key everywhere else.

How it was found and confirmed

By driving the real TUI over a pty against a real sshd, counting redraws (every frame starts with ESC[2J) and reading the last frame back. That is what showed the tell: the pane said "Timed out while waiting for handshake" and the "Unknown host" box was still drawn underneath it.

Same harness, before and after:

before after
TAB / j / h after the failed connect frames increment, screen never changes marker moves, pane goes up a directory
q does not exit — process must be killed exits cleanly

apps/cli/src/tui/host-key-prompt.test.ts adds 3 tests. The two that pin the fix fail without it; the third — y/n still answer, other keys still swallowed — passes either way, which is what makes it a regression test rather than a restatement.

Full suite: 238 tests, all passing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y5jnkZKX4AdPgBMzMosxE7

Sync to a remote endpoint and the TUI could stop responding: arrows did
nothing, the panes never moved, and q did not quit. Ctrl-C was the only way
out.

The prompt owns the keyboard while it is up, which is right — a fingerprint
should not be dismissable by mashing keys. What made it dangerous is that it
could outlive the connection that asked. Point a pane at a host DiskPush has
not seen and take fifteen seconds to compare the fingerprint: ssh2's
readyTimeout fires, connect rejects with "Timed out while waiting for
handshake", the pane shows that error — and the question stays on screen with
nobody behind it. Every key then goes to a `decide` whose promise no one
awaits, and because that branch only ever returned true, q was swallowed with
the rest. The app looked frozen because it was.

Two changes, and neither loosens the prompt:

  - session() clears the question in a finally. Whoever asked is gone, so the
    question goes with them.
  - q quits from inside the prompt. Everything else is still deliberately
    swallowed; a dialog you cannot leave is worse than one you can.

Found by driving the real TUI over a pty against a real sshd, which is also
what confirms the fix: before, TAB/j/h changed nothing and q hung; after,
the panes move and it exits cleanly. The two tests that pin this fail without
the change; the one asserting y/n still answer and other keys are still
swallowed passes either way, which is the point of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5jnkZKX4AdPgBMzMosxE7
@ralyodio
ralyodio merged commit faaf311 into main Aug 30, 2026
4 checks passed
@ralyodio
ralyodio deleted the fix/tui-zombie-hostkey-prompt branch August 30, 2026 05:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant